package net.neocat.tcx;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import net.neocat.tcx.file.*;
import net.neocat.tcx.speed.*;
import net.neocat.tcx.util.GeoUtils;
import net.neocat.tcx.util.LineSegment;
import org.apache.commons.cli.*;

import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.TimeUnit;

import static com.google.common.collect.Iterables.getLast;
import static java.lang.Math.min;

/**
 * Created with IntelliJ IDEA.
 * User: jschek
 * Date: 4/29/13
 * Time: 11:05 PM
 * To change this template use File | Settings | File Templates.
 */
public class Tool {

    private static final ImmutableSet<PointType> POINTS_REQUIRING_WARNING =
            ImmutableSet.of(PointType.Left, PointType.Right, PointType.Straight);

    private static final long epoch;

    static {
        Calendar calendar = Calendar.getInstance();
        calendar.set(2013, 01, 01, 00, 00, 00);
        epoch = calendar.getTimeInMillis();
    }

    private static final Option BIKE_MASS = OptionBuilder
            .hasArg()
            .withLongOpt("bikemass")
            .withDescription("Mass of bike in lbs (default=20)")
            .withArgName("lbs")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('b');

    private static final Option RIDER_MASS = OptionBuilder
            .hasArg()
            .withLongOpt("ridermass")
            .withDescription("Mass of rider in lbs (default=135)")
            .withArgName("lbs")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('r');

    private static final Option RIDER_HEIGHT = OptionBuilder
            .hasArg()
            .withLongOpt("riderheight")
            .withDescription("Height of rider in inches (default = 5' 7\" or 67')")
            .withArgName("inches")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('h');

    private static final Option WIND_SPEED = OptionBuilder
            .hasArg()
            .withLongOpt("windspeed")
            .withDescription("Wind speed in mph (default = 0)")
            .withArgName("mph")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('w');

    private static final Option POWER = OptionBuilder
            .hasArg()
            .withLongOpt("power")
            .withDescription("Average power in watts (default = 180)")
            .withArgName("watts")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('p');

    private static final Option CADENCE = OptionBuilder
            .hasArg()
            .withLongOpt("cadence")
            .withDescription("Average cadence in rpms (default=90)")
            .withArgName("rpms")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('c');

    private static final Option TEMPERATURE = OptionBuilder
            .hasArg()
            .withLongOpt("temperature")
            .withDescription("Temperature in F (default=68)")
            .withArgName("degrees")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('k');

    private static final Option TOLERANCE = OptionBuilder
            .hasArg()
            .withLongOpt("tolerance")
            .withDescription("Map tolerance in feet (default=15)")
            .withArgName("feet")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create('t');

    private static final Option TURN_WARNING = OptionBuilder
            .hasArg()
            .withLongOpt("turnwarning")
            .withDescription("Turn warning distance in feet (default=200)")
            .withArgName("feet")
            .withType(PatternOptionBuilder.NUMBER_VALUE)
            .create("tw");

    private static final Option TCX_FILES = OptionBuilder
            .isRequired()
            .hasArgs()
            .withLongOpt("files")
            .withDescription("TCX Files to process")
            .withArgName("file")
            .create("f");

    private static final Options options = new Options()
            .addOption(BIKE_MASS)
            .addOption(RIDER_HEIGHT)
            .addOption(RIDER_HEIGHT)
            .addOption(WIND_SPEED)
            .addOption(TEMPERATURE)
            .addOption(POWER)
            .addOption(CADENCE)
            .addOption(TOLERANCE)
            .addOption(TURN_WARNING)
            .addOption(TCX_FILES);

    private static transient CommandLine commandLine = null;

    public static void main(final String[] args) throws IOException, TcxException, ParseException {
        CommandLineParser parser = new PosixParser();
        try {
            commandLine = parser.parse(options, args, true);
        } catch (ParseException exp) {
            // oops, something went wrong
            System.err.println("Invalid arguments: " + exp.getMessage());
            HelpFormatter formatter = new HelpFormatter();
            formatter.printHelp("ant", options);
            System.exit(1);
        }

        for (String file : getStringList(TCX_FILES)) {
            System.out.println("Processing file " + file);
            processFile(new File(file));
        }
    }

    private static double getDouble(Option option, double defaultValue) throws ParseException {
        Number number = (Number) commandLine.getParsedOptionValue(option.getOpt());
        if (number == null) {
            return defaultValue;
        }
        return number.doubleValue();
    }

    private static int getInteger(Option option, int defaultValue) throws ParseException {
        Number number = (Number) commandLine.getParsedOptionValue(option.getOpt());
        if (number == null) {
            return defaultValue;
        }
        return number.intValue();
    }

    private static List<String> getStringList(Option option) throws ParseException {
        return Arrays.asList(commandLine.getOptionValues(option.getOpt()));
    }

    private static void processFile(File tcx) throws IOException, TcxException, ParseException {
        File tcxOut = new File(tcx.getParentFile(), Files.getNameWithoutExtension(tcx.getName()) + "-processed." + Files.getFileExtension(tcx.getName()));
        TcxCourse course = new TcxReader().readFile(tcx);

        SpeedCalculator calculator = new SpeedCalculatorBuilder()
                .setBikeMass(getDouble(BIKE_MASS, 20))
                .setRiderMass(getDouble(RIDER_MASS, 135))
                .setRiderHeight(getInteger(RIDER_HEIGHT, 5 * 12 + 7))
                .setBikeType(BikeType.RaceTops)
                .setTemperature(getDouble(TEMPERATURE, 68))
                .setWindSpeed(getDouble(WIND_SPEED, 0))
                .create();

        PowerInput powerInput = new RangePowerInputBuilder()
                .setGradeSamples(5)
                .useDefaultRanges(getDouble(POWER, 180))
                .create();

        TcxCourse newCourse = new Tool()
                .generateTurnWarningsWithDistanceInFeet(getDouble(TURN_WARNING, 200))
                .performGeneralizeWithToleranceInFeet(getDouble(TOLERANCE, 15))
                .setSpeedCalculator(calculator)
                .setCadence(getInteger(CADENCE, 90))
                .setPowerInput(powerInput)
                .processCourse(course);

        System.out.println("Old Track: " + course.getTrackPoints().size());
        System.out.println("Old Course: " + course.getCoursePoints().size());
        System.out.println("New Track Points: " + newCourse.getTrackPoints().size());
        System.out.println("New Course Points: " + newCourse.getCoursePoints().size());
        System.out.println("Total Time: " + TimeUnit.SECONDS.toMinutes(newCourse.getTotalTimeSeconds()) + " minutes");
        System.out.println("Total Distance: " + (newCourse.getTotalDistanceMeters() * 0.000621371) + " miles");
        new TcxWriter().write(tcxOut, newCourse);
        System.out.println("Written To: " + tcxOut.getCanonicalPath());
        System.out.println();
    }

    private double toleranceInMeters = 0;
    private double warningDistanceInMeters = 0;
    private SpeedCalculator speedCalculator = new ConstantSpeedCalculator(15);
    private PowerInput powerInput = new ConstantPowerInput(180);
    private int cadence = 90;

    public int getCadence() {
        return cadence;
    }

    public Tool setCadence(int cadence) {
        this.cadence = cadence;
        return this;
    }

    public PowerInput getPowerInput() {
        return powerInput;
    }

    public Tool setPowerInput(PowerInput powerInput) {
        this.powerInput = powerInput;
        return this;
    }

    public boolean isPerformGeneralize() {
        return toleranceInMeters > 0.0;
    }

    public Tool performGeneralizeWithToleranceInFeet(double toleranceInFeet) {
        return performGeneralizeWithTolerance(toleranceInFeet * 0.3048);
    }

    public Tool performGeneralizeWithTolerance(double toleranceInMeters) {
        this.toleranceInMeters = toleranceInMeters;
        return this;
    }

    public double getToleranceInMeters() {
        return toleranceInMeters;
    }

    public boolean isGenerateTurnWarnings() {
        return warningDistanceInMeters > 0;
    }

    public Tool generateTurnWarningsWithDistanceInFeet(double warningDistanceInFeet) {
        return generateTurnWarningsWithDistance(warningDistanceInFeet * 0.3048);
    }

    public Tool generateTurnWarningsWithDistance(double warningDistanceInMeters) {
        this.warningDistanceInMeters = warningDistanceInMeters;
        return this;
    }

    public double getWarningDistanceInMeters() {
        return warningDistanceInMeters;
    }

    public SpeedCalculator getSpeedCalculator() {
        return speedCalculator;
    }

    public Tool setSpeedCalculator(SpeedCalculator calculator) {
        this.speedCalculator = calculator;
        return this;
    }

    public TcxCourse processCourse(TcxCourse input) {
        final List<CoursePoint> originalCoursePoints = input.getCoursePoints();
        List<LineSegment> lineSegments = getLineSegments(originalCoursePoints, GeoUtils.toPositions(input.getTrackPoints()));
        List<CoursePoint> newCoursePoints = Lists.newArrayListWithCapacity(lineSegments.size() + 2);
        if (isGenerateTurnWarnings()) {
            generateCoursePointsAndUpdateLineSegments(newCoursePoints, lineSegments);

            if (!originalCoursePoints.isEmpty()) {
                //re-add original start
                if (newCoursePoints.get(0).getTimestamp() != originalCoursePoints.get(0).getTimestamp()) {
                    newCoursePoints.add(0, originalCoursePoints.get(0));
                }
                //re-add original end
                if (getLast(newCoursePoints).getTimestamp() != getLast(originalCoursePoints).getTimestamp()) {
                    newCoursePoints.add(getLast(originalCoursePoints));
                }
            }
        } else {
            newCoursePoints.addAll(originalCoursePoints);
        }

        List<TrackPoint> trackPoints = generateTrackPoints(lineSegments);
        newCoursePoints = matchCoursePointsTimeToTrackPointsTime(newCoursePoints, trackPoints);

        TrackPoint first = trackPoints.get(0);
        TrackPoint last = getLast(trackPoints);

        return new TcxCourse(
                input.getCourseName(),
                last.getDistanceMeters(),
                (int) (last.getTimestamp() - first.getTimestamp()) / 1000, //integer div
                first.getPosition(),
                last.getPosition(),
                trackPoints,
                newCoursePoints);
    }

    private List<CoursePoint> matchCoursePointsTimeToTrackPointsTime(List<CoursePoint> oldCoursePoints, List<TrackPoint> trackPoints) {
        List<CoursePoint> newCoursePoints = Lists.newArrayListWithExpectedSize(oldCoursePoints.size());
        Iterator<TrackPoint> trackPointsIterator = trackPoints.iterator();
        TrackPoint currentTrackPoint = trackPointsIterator.next();
        for (CoursePoint coursePoint : oldCoursePoints) {
            while (!coursePoint.getPosition().equals(currentTrackPoint.getPosition())) {
                currentTrackPoint = trackPointsIterator.next();
            }
            newCoursePoints.add(coursePoint.createCoursePointWithTimestamp(currentTrackPoint.getTimestamp()));
        }
        return newCoursePoints;
    }


    private List<TrackPoint> generateTrackPoints(List<LineSegment> lineSegments) {
        List<TrackPoint> trackPoints = Lists.newArrayList();

        long timestamp = epoch;
        double totalDistanceMeters = 0.0;

        GeoPosition previous = lineSegments.get(0).getFirst();
        trackPoints.add(createTrackPoint(timestamp, previous, totalDistanceMeters));

        for (LineSegment lineSegment : lineSegments) {
            for (GeoPosition position : lineSegment.getVertexes()) {
                if (position.equals(previous)) {
                    continue;
                }
                final double currentDistance = position.greatCircleDistanceMeters2D(previous);
                final double elevationGain = (position.getElevationMeters() - previous.getElevationMeters());
                final double grade = elevationGain / currentDistance * 100;
                final double power = powerInput.calculatePower(timestamp - epoch, TimeUnit.MILLISECONDS, grade);
                final double speedMetersPerSecond = speedCalculator.calculateMetersPerSecond(power, cadence, grade, position.getElevationMeters());
                final long elapsed = (long) (currentDistance / speedMetersPerSecond * 1000);
                System.out.printf("%5d minutes\t%3.1f%%\t%4.0f w\t%3.1f mph\n",
                        TimeUnit.MILLISECONDS.toMinutes(timestamp - epoch), grade, power, (speedMetersPerSecond * 2.23694));
                totalDistanceMeters += currentDistance;
                timestamp += elapsed;
                trackPoints.add(createTrackPoint(timestamp, position, totalDistanceMeters));

                previous = position;
            }
        }
        return trackPoints;
    }

    private TrackPoint createTrackPoint(long timestamp, GeoPosition position, double totalDistanceMeters) {
        return new TrackPoint(timestamp, position, position.getElevationMeters(), totalDistanceMeters);
    }

    private void generateCoursePointsAndUpdateLineSegments(List<CoursePoint> coursePoints, List<LineSegment> lineSegments) {
        for (ListIterator<LineSegment> iterator = lineSegments.listIterator(); iterator.hasNext(); ) {
            LineSegment lineSegment = iterator.next();
            final double segmentLengthInMeters = lineSegment.getGreatCircleDistanceMeters2D();
            final double turnDistance = min(segmentLengthInMeters * 0.75, getWarningDistanceInMeters());

            CoursePoint oldCoursePoint = (CoursePoint) lineSegment.getTags().get("CoursePoint");
            CoursePoint newCoursePoint = null;
            if (oldCoursePoint != null && POINTS_REQUIRING_WARNING.contains(oldCoursePoint.getPointType())) {
                lineSegment = lineSegment.insertPointAlongLine(segmentLengthInMeters - turnDistance);
                iterator.set(lineSegment);
                GeoPosition newPoint = lineSegment.getInterpolatedPoint() != null
                        ? lineSegment.getInterpolatedPoint()
                        : lineSegment.getLast();

                if (oldCoursePoint != null) {
                    newCoursePoint = oldCoursePoint.createCoursePointAt(newPoint);
                }
            } else {
                newCoursePoint = oldCoursePoint;
            }
            coursePoints.add(newCoursePoint);
        }
    }

    private List<LineSegment> getLineSegments(final List<CoursePoint> coursePoints, final List<GeoPosition> inputPositions) {
        List<LineSegment> lineSegments = Lists.newArrayListWithCapacity(coursePoints.size());
        LinkedList<CoursePoint> remainingCoursePoints = Lists.newLinkedList(coursePoints);

        List<GeoPosition> segmentPoints = Lists.newArrayListWithCapacity(inputPositions.size());
        segmentPoints.add(inputPositions.get(0));
        final boolean performGeneralize = isPerformGeneralize();
        for (GeoPosition currentPosition : inputPositions.subList(1, inputPositions.size())) {
            segmentPoints.add(currentPosition);
            CoursePoint matchingCoursePoint = findAndRemoveCoursePoint(remainingCoursePoints, currentPosition);
            if (matchingCoursePoint != null) {
                LineSegment segment = new LineSegment(segmentPoints, ImmutableMap.of("CoursePoint", matchingCoursePoint));
                if (performGeneralize) {
                    segment = segment.generalize(getToleranceInMeters());
                }
                lineSegments.add(segment);
                segmentPoints.clear();
                segmentPoints.add(currentPosition);
            }
        }
        if (segmentPoints.size() > 1) {
            CoursePoint matchingCoursePoint = findAndRemoveCoursePoint(remainingCoursePoints, getLast(segmentPoints));
            Map<String, Object> tags = matchingCoursePoint != null
                    ? ImmutableMap.<String, Object>of("CoursePoint", matchingCoursePoint)
                    : ImmutableMap.<String, Object>of();
            LineSegment segment = new LineSegment(inputPositions, tags);
            if (isPerformGeneralize()) {
                segment = segment.generalize(getToleranceInMeters());
            }
            lineSegments.add(segment);
        }
        return lineSegments;
    }

    private CoursePoint findAndRemoveCoursePoint(LinkedList<CoursePoint> coursePoints, GeoPosition position) {
        for (Iterator<CoursePoint> iterator = coursePoints.iterator(); iterator.hasNext(); ) {
            CoursePoint coursePoint = iterator.next();
            if (coursePoint.getPosition().equals(position)) {
                iterator.remove();
                return coursePoint;
            }
        }
        return null;
    }
}
